ひとりNavigation API Advent Calendar 08日目
https://gyazo.com/5b5bc99a37e49a7320ec99fed647a31f
これはひとりNavigation API Advent Calendarの8日目です
今日はReact Routerの内部でHistory APIがどのように使われているかをコードリーディングしていきます
DeepWikiに手伝ってもらいます
https://deepwiki.com/remix-run/react-router
packages/react-router/lib/router/history.ts
Historyインターフェース
code:ts
export interface History {
/**
* The last action that modified the current location. This will always be
* Action.Pop when a history instance is first created. This value is mutable.
*/
readonly action: Action;
/**
* The current location. This value is mutable.
*/
readonly location: Location;
/**
* Returns a valid href for the given to value that may be used as
* the value of an <a href> attribute.
*
* @param to - The destination URL
*/
createHref(to: To): string;
/**
* Returns a URL for the given to value
*
* @param to - The destination URL
*/
createURL(to: To): URL;
/**
* Encode a location the same way window.history would do (no-op for memory
* history) so we ensure our PUSH/REPLACE navigations for data routers
* behave the same as POP
*
* @param to Unencoded path
*/
encodeLocation(to: To): Path;
/**
* Pushes a new location onto the history stack, increasing its length by one.
* If there were any entries in the stack after the current one, they are
* lost.
*
* @param to - The new URL
* @param state - Data to associate with the new location
*/
push(to: To, state?: any): void;
/**
* Replaces the current location in the history stack with a new one. The
* location that was replaced will no longer be available.
*
* @param to - The new URL
* @param state - Data to associate with the new location
*/
replace(to: To, state?: any): void;
/**
* Navigates n entries backward/forward in the history stack relative to the
* current index. For example, a "back" navigation would use go(-1).
*
* @param delta - The delta in the stack index
*/
go(delta: number): void;
/**
* Sets up a listener that will be called whenever the current location
* changes.
*
* @param listener - A function that will be called when the location changes
* @returns unlisten - A function that may be used to stop listening
*/
listen(listener: Listener): () => void;
}
実際のHistory APIをそのまま使うのではなく用途を絞っている
HistoryStateインターフェース
code:ts
type HistoryState = {
usr: any;
key?: string;
idx: number;
};
usr - ユーザー状態
ユーザーがnavigate()や<Link state={}>で渡したカスタム状態データを格納
key - ロケーションキー
各ロケーションの一意識別子で、スクロール位置の復元などに使用される
初期値はdefault
idx - 履歴インデックス
履歴スタック内の現在位置を示す数値で、戻る/進むボタンの動作を管理
BrowserHistory
code:ts
export function createBrowserHistory(
options: BrowserHistoryOptions = {},
): BrowserHistory {
function createBrowserLocation(
window: Window,
globalHistory: Window"history",
) {
let { pathname, search, hash } = window.location;
return createLocation(
"",
{ pathname, search, hash },
// state defaults to null because window.history.state does
(globalHistory.state && globalHistory.state.usr) || null,
(globalHistory.state && globalHistory.state.key) || "default",
);
}
function createBrowserHref(window: Window, to: To) {
return typeof to === "string" ? to : createPath(to);
}
return getUrlBasedHistory(
createBrowserLocation,
createBrowserHref,
null,
options,
);
}
HashHistory
code:ts
export function createHashHistory(
options: HashHistoryOptions = {},
): HashHistory {
function createHashLocation(
window: Window,
globalHistory: Window"history",
) {
let {
pathname = "/",
search = "",
hash = "",
} = parsePath(window.location.hash.substring(1));
// Hash URL should always have a leading / just like window.location.pathname
// does, so if an app ends up at a route like /#something then we add a
// leading slash so all of our path-matching behaves the same as if it would
// in a browser router. This is particularly important when there exists a
// root splat route (<Route path="*">) since that matches internally against
// "/*" and we'd expect /#something to 404 in a hash router app.
if (!pathname.startsWith("/") && !pathname.startsWith(".")) {
pathname = "/" + pathname;
}
return createLocation(
"",
{ pathname, search, hash },
// state defaults to null because window.history.state does
(globalHistory.state && globalHistory.state.usr) || null,
(globalHistory.state && globalHistory.state.key) || "default",
);
}
function createHashHref(window: Window, to: To) {
let base = window.document.querySelector("base");
let href = "";
if (base && base.getAttribute("href")) {
let url = window.location.href;
let hashIndex = url.indexOf("#");
href = hashIndex === -1 ? url : url.slice(0, hashIndex);
}
return href + "#" + (typeof to === "string" ? to : createPath(to));
}
function validateHashLocation(location: Location, to: To) {
warning(
location.pathname.charAt(0) === "/",
`relative pathnames are not supported in hash history.push(${JSON.stringify(
to,
)})`,
);
}
return getUrlBasedHistory(
createHashLocation,
createHashHref,
validateHashLocation,
options,
);
}
getUrlBasedHistoryというのがHistory API操作する管轄っぽい
code:ts
function getUrlBasedHistory(
getLocation: (window: Window, globalHistory: Window"history") => Location,
createHref: (window: Window, to: To) => string,
validateLocation: ((location: Location, to: To) => void) | null,
options: UrlHistoryOptions = {},
): UrlHistory {
let { window = document.defaultView!, v5Compat = false } = options;
let globalHistory = window.history;
let action = Action.Pop;
let listener: Listener | null = null;
let index = getIndex()!;
// Index should only be null when we initialize. If not, it's because the
// user called history.pushState or history.replaceState directly, in which
// case we should log a warning as it will result in bugs.
if (index == null) {
index = 0;
globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
}
function getIndex(): number {
let state = globalHistory.state || { idx: null };
return state.idx;
}
function handlePop() {
action = Action.Pop;
let nextIndex = getIndex();
let delta = nextIndex == null ? null : nextIndex - index;
index = nextIndex;
if (listener) {
listener({ action, location: history.location, delta });
}
}
function push(to: To, state?: any) {
action = Action.Push;
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);
index = getIndex() + 1;
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
// try...catch because iOS limits us to 100 pushState calls :/
try {
globalHistory.pushState(historyState, "", url);
} catch (error) {
// If the exception is because state can't be serialized, let that throw
// outwards just like a replace call would so the dev knows the cause
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#shared-history-push/replace-state-steps
// https://html.spec.whatwg.org/multipage/structured-data.html#structuredserializeinternal
if (error instanceof DOMException && error.name === "DataCloneError") {
throw error;
}
// They are going to lose state here, but there is no real
// way to warn them about it since the page will refresh...
window.location.assign(url);
}
if (v5Compat && listener) {
listener({ action, location: history.location, delta: 1 });
}
}
function replace(to: To, state?: any) {
action = Action.Replace;
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);
index = getIndex();
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.replaceState(historyState, "", url);
if (v5Compat && listener) {
listener({ action, location: history.location, delta: 0 });
}
}
function createURL(to: To): URL {
return createBrowserURLImpl(to);
}
let history: History = {
get action() {
return action;
},
get location() {
return getLocation(window, globalHistory);
},
listen(fn: Listener) {
if (listener) {
throw new Error("A history only accepts one active listener");
}
window.addEventListener(PopStateEventType, handlePop);
listener = fn;
return () => {
window.removeEventListener(PopStateEventType, handlePop);
listener = null;
};
},
createHref(to) {
return createHref(window, to);
},
createURL,
encodeLocation(to) {
// Encode a Location the same way window.location would
let url = createURL(to);
return {
pathname: url.pathname,
search: url.search,
hash: url.hash,
};
},
push,
replace,
go(n) {
return globalHistory.go(n);
},
};
return history;
}
getUrlBasedHistoryを深ぼる
pushメソッド
code:ts
// try...catch because iOS limits us to 100 pushState calls :/
try {
globalHistory.pushState(historyState, "", url);
} catch (error) {
// If the exception is because state can't be serialized, let that throw
// outwards just like a replace call would so the dev knows the cause
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#shared-history-push/replace-state-steps
// https://html.spec.whatwg.org/multipage/structured-data.html#structuredserializeinternal
if (error instanceof DOMException && error.name === "DataCloneError") {
throw error;
}
// They are going to lose state here, but there is no real
// way to warn them about it since the page will refresh...
window.location.assign(url);
}
iOS端末での100回のpushState呼び出し制限に対応
stateが構造化シリアライズできない場合は例外を投げる
例外が発生した場合は、window.location.assign()を使用してフォールバックしている
その場合、ページのリロードが起きてしまうので、stateが消えることをユーザーに事前に知らせる手段がない
replaceメソッド
code:ts
let location = createLocation(history.location, to, state);
if (validateLocation) validateLocation(location, to);
index = getIndex();
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.replaceState(historyState, "", url);
初期化時にインデックス情報がない場合にもreplaceState()を使用して状態を初期化
packages/react-router/lib/dom/lib.tsx
scrollRestoration
code:ts
React.useEffect(() => {
window.history.scrollRestoration = "manual";
return () => {
window.history.scrollRestoration = "auto";
};
}, []);
マウント時: window.history.scrollRestoration = "manual"でブラウザの自動スクロール復元を無効化
アンマウント時: クリーンアップ関数でwindow.history.scrollRestoration = "auto"に戻す
コンポーネントが削除された後もブラウザの標準動作を維持する
他のライブラリや機能に影響を与えないようにする
メモリリークや予期せぬ動作を防止する
code:ts
warning(
blockerFunctions.size === 0 || delta != null,
"You are trying to use a blocker on a POP navigation to a location " +
"that was not created by @remix-run/router. This will fail silently in " +
"production. This can happen if you are navigating outside the router " +
"via window.history.pushState/window.location.hash instead of using " +
"router navigation APIs. This can also happen if you are using " +
"createHashRouter and the user manually changes the URL.",
);
外部から直接window.history.pushStateを呼び出すのではなく、ルーターのAPIを使用することを推奨している